Skip to content

refactor: remove request_ctx ContextVar, thread Context explicitly#2203

Open
maxisbey wants to merge 2 commits intomainfrom
remove-request-ctx-contextvar
Open

refactor: remove request_ctx ContextVar, thread Context explicitly#2203
maxisbey wants to merge 2 commits intomainfrom
remove-request-ctx-contextvar

Conversation

@maxisbey
Copy link
Contributor

@maxisbey maxisbey commented Mar 2, 2026

Removes the request_ctx ContextVar and threads Context explicitly through the MCPServer request-handling chain.

Motivation and Context

The request_ctx ContextVar in mcp.server.lowlevel.server was redundant: the lowlevel server already passes ServerRequestContext as the first argument to every _handle_* method. The ContextVar was a second mechanism carrying the same value, used only by MCPServer.get_context().

This PR removes the ContextVar entirely, making the data flow explicit. _handle_* methods construct the high-level Context at the lowlevel→MCPServer boundary and pass it through.

Part of #2112 (context refactor) / #1684 (contextvars cleanup).

How Has This Been Tested?

  • All existing tests pass (1123 passed, 98 skipped, 1 xfailed)
  • New tests for the context-required guards in Tool.run(), Prompt.render(), ResourceTemplate.create_resource()
  • uv run --frozen pyright src/ tests/ — 0 errors
  • uv run --frozen ruff check — clean

Breaking Changes

Yes — documented in docs/migration.md:

  • MCPServer.get_context() removed — use ctx: Context parameter injection in tool/resource/prompt functions instead (the existing recommended pattern)
  • request_ctx ContextVar removed from mcp.server.lowlevel.server — code importing it directly will need to use parameter injection
  • MCPServer.call_tool(), read_resource(), get_prompt() now accept an optional context: Context | None = None parameter — backward-compatible for callers that don't pass it
  • New behavior: if a tool/resource/prompt declares a ctx: Context parameter but is called with context=None, a clear error is raised (ToolError for tools, ValueError for prompts/resource templates). Previously None was silently injected.

Types of changes

  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Context class moved from mcp.server.mcpserver.server to mcp.server.mcpserver.context. The public import path (from mcp.server.mcpserver import Context) is unchanged via __init__.py re-export. The old direct-module path (from mcp.server.mcpserver.server import Context) also still works because server.py imports Context at module level.

Exception hierarchy cleanup (making prompts/resources raise MCPServerError subclasses instead of ValueError) was considered but deferred to #1742 (error taxonomy) to avoid scope creep — see that issue for the full analysis of the existing inconsistencies.

AI Disclaimer

The request_ctx ContextVar in mcp.server.lowlevel.server was redundant
with the ServerRequestContext already passed as the first argument to
every _handle_* method. This removes the ContextVar entirely and threads
Context explicitly.

Changes:
- MCPServer.get_context() removed — use ctx: Context parameter injection
  in tool/resource/prompt functions instead
- MCPServer.call_tool/read_resource/get_prompt now accept an optional
  context: Context | None = None parameter; _handle_* methods construct
  the Context at the lowlevel boundary and pass it through
- Context class moved from server.py to its own context.py module (still
  re-exported from mcp.server.mcpserver)
- Tool.run/Prompt.render/ResourceTemplate.create_resource now raise a
  clear error if the registered function requires a Context but none was
  provided, instead of silently injecting None

Github-Issue: #2112
Github-Issue: #1684
@maxisbey maxisbey requested review from Kludex and felixweinberger and removed request for Kludex March 2, 2026 14:17
Comment on lines +147 to +148
if self.context_kwarg is not None and context is None:
raise ValueError(f"Prompt {self.name!r} requires a Context, but none was provided")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would the Context be None here? 🤔

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, probably it's RuntimeError, if anything.

Copy link
Contributor Author

@maxisbey maxisbey Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly the way Context is optional is rather weird through the whole call chain. If it's None it's sometimes just not injected which also breaks stuff.

After taking a bit of time to walk through it, I think it'd be better to leave it as optional on the MCPServer methods itself, but then make it required on the rest of the call stack, which is essentially the same functionality that existed before. If you called the previous MCPServer.get_context method it would just construct a new one if none existed.

So will do that to restore what was here before and remove some of the weird optional handling through the rest of the call chain.

Comment on lines +110 to +111
if self.context_kwarg is not None and context is None:
raise ValueError(f"Resource template {self.name!r} requires a Context, but none was provided")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question.

Comment on lines +104 to +105
if self.context_kwarg is not None and context is None:
raise ToolError(f"Tool {self.name!r} requires a Context, but none was provided")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kind a the same question... 👀

Comment on lines +15 to +17
from mcp.server.elicitation import (
elicit_url as _elicit_url,
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's this?

Suggested change
from mcp.server.elicitation import (
elicit_url as _elicit_url,
)
from mcp.server.elicitation import elicit_url

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, got it.

Suggested change
from mcp.server.elicitation import (
elicit_url as _elicit_url,
)
from mcp.server.elicitation import elicit_url as _elicit_url

Still...

Comment on lines +66 to +67
# TODO(Marcelo): We should drop this kwargs parameter.
**kwargs: Any,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should follow what Marcelo said.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I wasn't actually changing the Context class at all, just moving it for now

"""
progress_token = self.request_context.meta.get("progress_token") if self.request_context.meta else None

if progress_token is None: # pragma: no cover
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if progress_token is None: # pragma: no cover
if progress_token is None: # pragma: no branch

related_request_id=self.request_id,
)

async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this AnyUrl?

Suggested change
async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]:
async def read_resource(self, uri: str) -> Iterable[ReadResourceContents]:

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keen to follow up with a different PR to actually refactor the Context class itself, but this one is just about moving it out and removing the context_var access.

Comment on lines +232 to +262
async def close_sse_stream(self) -> None:
"""Close the SSE stream to trigger client reconnection.

This method closes the HTTP connection for the current request, triggering
client reconnection. Events continue to be stored in the event store and will
be replayed when the client reconnects with Last-Event-ID.

Use this to implement polling behavior during long-running operations -
the client will reconnect after the retry interval specified in the priming event.

Note:
This is a no-op if not using StreamableHTTP transport with event_store.
The callback is only available when event_store is configured.
"""
if self._request_context and self._request_context.close_sse_stream: # pragma: no cover
await self._request_context.close_sse_stream()

async def close_standalone_sse_stream(self) -> None:
"""Close the standalone GET SSE stream to trigger client reconnection.

This method closes the HTTP connection for the standalone GET stream used
for unsolicited server-to-client notifications. The client SHOULD reconnect
with Last-Event-ID to resume receiving notifications.

Note:
This is a no-op if not using StreamableHTTP transport with event_store.
Currently, client reconnection for standalone GET streams is NOT
implemented - this is a known gap.
"""
if self._request_context and self._request_context.close_standalone_sse_stream: # pragma: no cover
await self._request_context.close_standalone_sse_stream()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did we find out the answers for what I asked before? When would the developer actually use those???

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iirc I couldn't find actual uses for it publically, but it's a required part of the spec the sdk needs to support

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't seem a reasonable answer. So we have a public API that is required by a spec that actually no one uses? Is the spec written for the sake of having a spec? Can we please find the use case of it?

Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants